Skip to content

feat(compile): make on.pr a first-class trigger via synthetic CI-derived PR context#922

Open
jamesadevine wants to merge 19 commits into
mainfrom
feat/synthetic-pr-from-ci
Open

feat(compile): make on.pr a first-class trigger via synthetic CI-derived PR context#922
jamesadevine wants to merge 19 commits into
mainfrom
feat/synthetic-pr-from-ci

Conversation

@jamesadevine

@jamesadevine jamesadevine commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #916.

Makes on: pr a first-class trigger in Azure Repos by deriving PR context from the ADO REST API on CI-triggered builds. No Build Validation branch policy required.

Why

Azure DevOps Services ignores the YAML pr: block unless a per-branch Build Validation policy is registered server-side. Without that policy, a git push to a feature branch fires the compiled pipeline as Build.Reason = IndividualCI even when an open PR exists, so:

  • scripts/ado-script/src/gate/bypass.ts takes the "Not a Pull Request build — gate passes automatically" path and never gets PR identifiers.
  • scripts/ado-script/src/exec-context-pr/index.ts is conditioned on eq(Build.Reason, 'PullRequest') and is skipped entirely, so aw-context/pr/{base.sha,head.sha} and the agent-prompt PR-context fragment are never staged.
  • PR-aware agents (e.g. PR reviewers) silently degrade.

This defeated the "one markdown file is the whole workflow" promise — registering an ado-aw agent shouldn't require a separate ADO-UI click for every target branch.

What

Adds a new front-matter knob on.pr.mode (default synthetic). The two modes give the author a single choice between "no policy required" and "policy installed":

on.pr.mode Synthesis wiring Top-level trigger: Use when
synthetic (default) emitted ADO default (all branches) No branch policy. The vast majority of agents.
policy omitted trigger: none Operator installed a Build Validation branch policy. Real PR-typed builds only.

When mode: synthetic (the default):

  1. Setup-job script exec-context-pr-synth.js queries the ADO REST API at build time:

    • BUILD_REASON == PullRequest → no-op (real PR build owns the path).
    • BUILD_REPOSITORY_PROVIDER == GitHub → no-op (GitHub repos already get correct pr: semantics).
    • Decode PR_SYNTH_SPEC (base64 JSON of pr.branches + pr.paths).
    • Fetch active PRs by sourceRefName == Build.SourceBranch, filter by targetRefName matching pr.branches.
    • Exactly one match → check pr.paths against the iteration-API change list. Otherwise → emit AW_SYNTHETIC_PR_SKIP=true with reason, agent self-skips cleanly.
    • On match: emit AW_SYNTHETIC_PR=true + AW_SYNTHETIC_PR_ID/SOURCEBRANCH/TARGETBRANCH/IS_DRAFT as Setup-job outputs.
  2. Downstream env coalescing: gate.js and exec-context-pr.js env blocks switch ADO_PR_ID, ADO_SOURCE_BRANCH, ADO_TARGET_BRANCH, SYSTEM_PULLREQUEST_* to $[ coalesce(variables['System.PullRequest.*'], dependencies.Setup.outputs['synthPr.*']) ] so real PR builds remain bit-identical while CI-triggered builds pick up the synthesised identifiers.

  3. Gate bypass update: gate/bypass.ts checks AW_SYNTHETIC_PR === 'true' and skips the "not a PR build" auto-pass when the synthPr step promoted the run.

  4. Agent-job condition update: dependsOn condition gains an AW_SYNTHETIC_PR_SKIP guard and a broadened PR clause accepting real-PR / synthetic-promotion / gate-passed.

When mode: policy: none of the synth wiring above is emitted; the compiler additionally emits trigger: none so feature-branch pushes do not queue duplicate CI builds alongside the policy-driven PR build. Every PR update fires exactly one PR-typed build.

Why the CI trigger is intentionally not auto-narrowed in mode: synthetic

An earlier iteration auto-emitted a top-level trigger: block mirroring pr.branches.include to spare unrelated branches from queueing self-skipping builds. That was a logic bug: pr.branches.include lists PR target branches (e.g. main), but ADO trigger: fires on pushes to the listed branches. Narrowing to [main] suppressed CI on the feature branches synthPr actually needs to react to — pushing to feature/x with an open PR feature/x → main would never queue a build, defeating the entire synthetic-from-ci feature. The compiler therefore leaves the top-level trigger: at the ADO default ("trigger on every branch") in synth mode, and relies on the synthPr Setup step's existing fast-exit for cost control: a single listActivePullRequestsBySourceRef call returns [] on branches without a matching PR and the Agent job self-skips via AW_SYNTHETIC_PR_SKIP=true.

Decisions frozen in design

Decision Value
Knob name on.pr.mode, default synthetic (alternative: policy)
Match rule Exactly one open PR by sourceRefName whose targetRefName matches pr.branches.include/exclude
Path filtering Enforced against iteration-API change list (ADO ignores pr.paths on CI)
Real PR builds Bit-identical — synth step no-ops via step-level condition + runtime check
GitHub-typed repos Runtime no-op (compiler emits identical YAML; bundle detects BUILD_REPOSITORY_PROVIDER=GitHub)
Skip path Single logInfo line + AW_SYNTHETIC_PR_SKIP=true. Never red, never noisy.

Hard prerequisite

#915 (the addBuildTag("pr-gate:passed") colon bug) had to land first — every synthetic-PR gate path would otherwise die on the build-tag REST call. Already merged via #917 (4122a640).

Test plan

Automated (all green)

Surface Before After
Rust bin tests 1797 1811
Rust integration tests (compiler_tests) 124 129
Script unit tests (vitest) 251 281
Script smoke tests 6 6

Highlights:

  • cargo test --bins — 1811 pass, 0 fail
  • cargo test --test compiler_tests — 129 pass, 0 fail (includes 2 new snapshot fixtures: synthetic-pr-default.md asserts full synth wiring with no narrowed CI trigger; pr-mode-policy.md asserts the policy mode emits zero synth artefacts AND trigger: none)
  • 26 new vitest cases in scripts/ado-script/src/exec-context-pr-synth/__tests__/ (match.ts glob semantics + index.ts runtime contract with mocked ADO client: real-PR no-op, GitHub no-op, spec decode errors, zero/multi-match skips, path-filter rejection, happy path, draft PR)
  • 4 new gate bypass tests (AW_SYNTHETIC_PR=true suppresses bypass for PR specs only)
  • 4 schema deserialisation tests for on.pr.mode (default→synthetic, explicit synthetic, explicit policy, invalid value rejected)
  • 3 build_pr_synth_spec tests (round-trip, empty arrays, 8 KiB size cap)
  • 2 generate_agentic_depends_on tests for the synth-skip + broader PR clause
  • 3 generate_ci_trigger tests covering the two-mode contract (synth mode keeps ADO default, policy mode emits trigger: none, pipeline-completion trigger still wins)
  • 2 new AdoScriptExtension::setup_steps tests for synth-step emission (without and with a gate)
  • Bash-step lint passes (the new synth step is a one-line node … under set -euo pipefail)

Regression sweep

Recompiled 29 of 33 existing fixtures (the other 4 — runtime_imports_author_marker_* — expectedly need sibling files only the test harness copies). Confirmed:

  • 6 fixtures with on.pr correctly carry the new synth wiring
  • 23 fixtures without on.pr show zero synth artefacts
  • Zero unintended diffs across non-on.pr agents

Manual end-to-end (deferred to a separate task)

Per the implementation plan, the end-to-end demo against msazuresphere/4x4/azure-devops-agentic-pipelines PR #38551 (reviewer pipeline runs without a Build Validation branch policy, producing review comments + ado-aw audit <buildId> showing a clean aw-context/pr/{base.sha,head.sha} stage and no missing-PR-context warnings) will be performed after merge. The 12-commit history is deliberately reviewable in isolation.

Files changed

  • Schema: src/compile/types.rsPrTriggerConfig.synthetic_from_ci
  • Compiler: src/compile/{filter_ir,common,pr_filters,extensions/ado_script,extensions/exec_context/{mod,pr},extensions/mod}.rs
  • Runtime bundle: scripts/ado-script/src/exec-context-pr-synth/{index,match,spec}.ts + tests + harness; scripts/ado-script/src/{gate/bypass,shared/ado-client}.ts
  • Build: scripts/ado-script/package.json + .gitignore; .github/workflows/release.yml (pack the new bundle into ado-script.zip)
  • Tests: tests/compiler_tests.rs + tests/fixtures/synthetic-pr-{default,opt-out}.md
  • Docs: docs/front-matter.md (new "PR Triggering in Azure Repos" section); prompts/{create,update,debug}-ado-agentic-workflow.md cross-links

jamesadevine and others added 13 commits June 8, 2026 22:19
Default true. Plumbs the schema through PrTriggerConfig with a serde-renamed field; existing struct-literal call sites use ..Default::default() for forward-compat.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Serializes on.pr branches/paths to base64-encoded JSON consumed by the upcoming exec-context-pr-synth.js bundle. Mirrors GATE_SPEC encoding and adds an 8 KiB defence-in-depth cap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds skeleton index.ts (no-op main) and match.ts (picomatch-based branch/path glob helpers with refs/heads/ and leading-slash normalisation). Wires the bundle into the npm build/clean/test:smoke chain and the release.yml ado-script.zip packager. Adds picomatch as a direct dependency so ncc bundles it deterministically.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Decodes PR_SYNTH_SPEC, no-ops on real PR builds and GitHub-typed repos, queries ADO REST for active PRs by sourceRefName, enforces exactly-one-match + target-branch filter + path filter, emits AW_SYNTHETIC_PR* outputs consumed by gate.js and exec-context-pr.js. Adds listActivePullRequestsBySourceRef to shared/ado-client.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
26 new tests across match.ts (glob normalisation + include/exclude semantics) and index.ts (real-PR no-op, GitHub no-op, spec decode errors, zero/multi-match skips, path filter rejection, happy path with all five AW_SYNTHETIC_PR* outputs). Adds an ESM entry-point guard to index.ts so importing main() does not trigger top-level process.exit.

Removes the source-branch pre-filter; pr.branches filters the TARGET ref per the issue spec, not the build source.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When the upstream synthPr Setup-job step has elected a CI build for PR treatment, the gate must run the full PR-spec predicates instead of auto-passing via the 'not a PullRequest build' bypass. Only PullRequest specs honour the override; PipelineCompletion specs are unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds EXEC_CONTEXT_PR_SYNTH_PATH constant, synthetic_pr_active + pr_trigger_for_synth fields to AdoScriptExtension, synthetic_pr_step() generator, and wires collect_extensions to populate the flags from on.pr.synthetic-from-ci. Setup-job emits the synthPr step BEFORE prGate so downstream env coalescing can read the dependencies.Setup.outputs['synthPr.*'] values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-context-pr

Extends compile_gate_step_external with a synthetic_pr_active flag. When true and ctx == PullRequest, ADO_PR_ID / ADO_SOURCE_BRANCH / ADO_TARGET_BRANCH are emitted as coalesce(variables.System.PullRequest.*, dependencies.Setup.outputs.synthPr.*) so the gate evaluator picks up either the real PR variables or the synthPr Setup-job outputs. Also exports AW_SYNTHETIC_PR so gate/bypass.ts can detect the synthetic-promotion case. PrContextContributor gains the same synthetic_pr_active flag and switches SYSTEM_PULLREQUEST_PULLREQUESTID + SYSTEM_PULLREQUEST_TARGETBRANCH to the coalesced form, with the step condition broadened to accept either real or synthetic PR. This also closes compile-exec-context-cond.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extends generate_agentic_depends_on with a synthetic_pr_active flag. When true the condition gains a leading ne(synthPr.AW_SYNTHETIC_PR_SKIP, true) guard plus a broadened PR clause accepting real PR builds, synthPr promotion, or gate-passed. Threads the flag from compile_shared via front_matter.pr_trigger().synthetic_from_ci. Existing callers default to false (no behavioural change). Adds two unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…s on

When on.pr.synthetic-from-ci is on (default) and on.pr.branches.include is non-empty, emit a top-level trigger: block mirroring those branches so CI fires only on the configured set. Without this, ADO would queue a build for every push and most would be wasted compute (synthPr would skip them). Pipeline/schedule suppression still wins, synthetic-from-ci: false preserves the previous default, and an empty include list disables the narrowing. Five new unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… opt-out

Adds two fixtures and two integration tests. Fixture A asserts the full synth wiring is emitted (synthPr step, PR_SYNTH_SPEC env, broadened exec-context-pr.js condition, AW_SYNTHETIC_PR_SKIP guard, narrowed trigger block). Fixture B asserts ALL synth artefacts are absent under synthetic-from-ci: false (substring-negation back-compat guard, no stored baseline needed). The GitHub-resource case planned as fixture C is omitted because it produces the same YAML as A; the runtime no-op is covered by the bundle's vitest suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… files

front-matter.md gains the new knob in the example block and a 'PR Triggering in Azure Repos' section walking through why the feature exists, the 7-step runtime contract, the auto-narrowed CI trigger, and how to opt out. The three prompt files (create/update/debug) each cross-link to the new section with a one-paragraph note tailored to their context.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: The design and implementation are solid, but there is one logic bug in the CI trigger narrowing that would silently break the feature for the typical PR workflow (feature branch → PR → main), plus a minor injection surface to verify.


Findings

🐛 Bugs / Logic Issues

  • src/compile/common.rs generate_ci_trigger — narrowed trigger: uses target branches, not source branches

    The PR's synthetic-from-ci mechanism works by:

    1. A developer pushes to feature/x → ADO fires CI for feature/x (Build.SourceBranch = refs/heads/feature/x)
    2. exec-context-pr-synth.js fetches active PRs whose sourceRefName = refs/heads/feature/x, filters by targetRefName matching pr.branches.include
    3. On a match, the build is promoted to PR semantics

    For step 1 to happen, the compiled pipeline's trigger: block must fire for feature/x. But generate_ci_trigger emits:

    trigger:
      branches:
        include:
          - 'main'    # pr.branches.include — these are PR TARGET branches

    ADO trigger: fires on pushes to the listed branches. With include: [main], only pushes to main trigger the pipeline. Pushes to feature/x are suppressed, so exec-context-pr-synth.js never runs on those builds.

    The test harness confirms the intended runtime contract: BUILD_SOURCEBRANCH = refs/heads/feature/x is the default in harness.ts::makeEnv, and the index tests simulate CI builds on feature branches. But those CI builds will never occur if the compiled trigger only includes target branches.

    Fix: Remove the trigger narrowing in branch 2 of generate_ci_trigger. Without an explicit trigger: block, ADO defaults to all-branches CI, which is what synthPr needs. The AW_SYNTHETIC_PR_SKIP fast-exit in the Setup job already handles wasted builds cheaply (one listActivePullRequestsBySourceRef call that returns [] for branches with no PRs). Alternatively, the narrowing should be documented as an opt-in trade-off where users accept that only direct-to-target-branch pushes get synthPr processing.

    The five generate_ci_trigger tests and the synthetic-pr-default.md snapshot fixture all pass because they test the emitted YAML, not the runtime effect of narrowing on feature branch builds.

🔒 Security Concerns

  • scripts/ado-script/src/exec-context-pr-synth/index.ts:163-167setOutput with API-derived branch names

    setOutput("AW_SYNTHETIC_PR_TARGETBRANCH", pr.targetRefName ?? "");
    setOutput("AW_SYNTHETIC_PR_SOURCEBRANCH", pr.sourceRefName ?? sourceBranch);

    pr.targetRefName and pr.sourceRefName come directly from the ADO REST API. If setOutput in vso-logger.ts doesn't sanitise values for ##vso[...] command injection, a repository owner who controls branch names could craft refs/heads/main##vso[task.setSecret]secretValue to inject logging commands. This is the same class of risk the project already guards against for addBuildTag's colon (fix(gate): use '.' separator in build tags so ADO doesn't reject ':' in REST path #917).

    ADO branch naming rules in practice make real-world exploitation unlikely, but it's worth confirming that vso-logger.ts::setOutput applies the same logInfo-style sanitisation used for bypass_label in bypass.ts, or add explicit value sanitisation here. (Low priority.)


✅ What Looks Good

  • Spec validation is hardened in the right direction: decodeSpec in spec.ts hard-fails on any malformed PR_SYNTH_SPEC rather than soft-skipping — correct, since treating spec corruption as a skip would silently widen the synth attack surface.
  • build_pr_synth_spec size cap matches the GATE_SPEC ceiling (8 KiB); the oversize test with 1000 branch globs is a solid regression guard.
  • Coalesce expressions make real PR builds bit-identical: when synthPr step doesn't run (Build.Reason = PullRequest), coalesce(null, '') returns '', leaving real System.PullRequest.* variables in play.
  • synthetic_pr_active is true but pr_trigger_for_synth is None invariant check in AdoScriptExtension::setup_steps — good defensive programming for the compile-time structural contract.
  • Base64-encoded PR_SYNTH_SPEC is safe to embed in a YAML double-quoted scalar (base64 alphabet [A-Za-z0-9+/=] has no YAML double-quoted special characters).
  • AW_SYNTHETIC_PR_SKIP guard placement in generate_agentic_depends_on is correct: it's evaluated first (ne(synthPr.AW_SYNTHETIC_PR_SKIP, 'true') before the or(Build.Reason, ...) clause) ensuring a synth-skip decision is always honoured over other activation reasons.
  • Step-level condition on synthPr step (ne(Build.Reason, 'PullRequest')) is the right defence-in-depth: even if the downstream coalesce expressions were wrong, the step would never execute on a real PR build.
  • Test coverage is comprehensive: round-trip spec tests, runtime contract tests with mocked ADO client (real-PR no-op, GitHub no-op, zero/multi-match skips, path-filter rejection, happy path, draft PR), snapshot fixtures with substring-negation back-compat guard.

Generated by Rust PR Reviewer for issue #922 · sonnet46 3.4M ·

The narrowed trigger emitted by branch 2 of generate_ci_trigger used pr.branches.include as the trigger include list. Those are PR target branches (e.g. 'main'), but ADO trigger: fires on pushes TO listed branches, so narrowing to [main] suppressed CI on the feature branches synthPr actually needs to react to: a push to feature/x with an open PR feature/x -> main would never queue a build, defeating the entire synthetic-from-ci feature.

Remove branch 2 of generate_ci_trigger, the four narrowing-shape unit tests, and the narrowed-trigger assertion in test_synthetic_pr_default_emits_full_synth_wiring. Add a positive 'does not narrow' unit test, a negative integration assertion, and keep the pipeline-trigger priority test. Update docs/front-matter.md and prompts/create-ado-agentic-workflow.md to explain why narrowing is intentionally absent.

Cost concern from the original commit (a217df1) is addressed by the synthPr Setup step's existing fast-exit: a single listActivePullRequestsBySourceRef call returns [] for branches without a matching PR and the Agent job self-skips via AW_SYNTHETIC_PR_SKIP.

Addresses Rust PR Reviewer feedback on #922.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Needs changes — good architecture overall, but one logic regression and one misleading doc fragment.

Findings

🐛 Bugs / Logic Issues

  • src/compile/common.rs:2409-2417 — filter gate bypassed for real PR builds when synthetic-from-ci: true

    When synthetic_pr_active=true (the default for any on.pr: config) and has_pr_filters=true, the new agent-job dependsOn condition is:

    or(
      eq(variables['Build.Reason'], 'PullRequest'),    // arm a — unconditional for real PRs
      eq(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], 'true'),   // arm b
      eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')          // arm c
    )
    

    Arm (a) is unconditionally true on every real PR build. This is a regression from the original condition:

    or(
      ne(variables['Build.Reason'], 'PullRequest'),  // non-PR always passes
      eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')  // PR requires gate
    )
    

    The original required SHOULD_RUN=true for real PR builds; the new condition does not.

    When it bites: the gate soft-skips when SYSTEM_ACCESSTOKEN is unavailable — it sets SHOULD_RUN=false and exits 0 rather than cancelling the build. In that scenario a real PR with a mismatching title/label/draft filter will have succeeded()=true and arm(a)=true, so the agent runs despite the filter saying no. Since synthetic-from-ci defaults to true, this affects all existing agents with on.pr + filters:.

    Suggested fix — require the gate to pass in both PR paths:

    // synthetic_pr_active && has_pr_filters
    parts.push(
        r"and(
         or(
           eq(variables['Build.Reason'], 'PullRequest'),
           eq(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], 'true')
         ),
         eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')
       )"
        .to_string(),
    );

    This preserves "run if (real PR or synthetic) AND gate passed" rather than treating either status as an unconditional pass.

📄 Documentation Bug

  • prompts/update-ado-agentic-workflow.md:51 — incorrect claim:

    auto-narrows the top-level trigger: block to those branches.

    The code does not do this — there are tests (test_generate_ci_trigger_synthetic_pr_does_not_narrow) and a multi-paragraph design note in docs/front-matter.md explaining exactly why CI-trigger narrowing was removed as a logic bug. This sentence is a leftover from an earlier iteration. The create-ado-agentic-workflow.md prompt got this right; update-ado-agentic-workflow.md didn't.

⚠️ Suggestions

  • scripts/ado-script/src/shared/ado-client.ts:106 — comment says "first page (200 PRs)" but getPullRequests is called without a top argument; the azure-devops-node-api default is 100. Behaviour is correct (exactly-one contract catches pathological cases anyway), but the comment is misleading.

✅ What Looks Good

  • bypass.ts: the synthetic guard requires both spec.context.build_reason === "PullRequest" and AW_SYNTHETIC_PR === "true", so a stray env var cannot suppress bypass on a pipeline-completion gate spec.
  • spec.ts treats decode corruption as a hard error rather than a soft skip — correct, since silently widening the matched-branch set would be a security gap.
  • synthetic_pr_step() has a step-level ne(Build.Reason, 'PullRequest') condition so real PR builds see zero overhead.
  • PrTriggerConfig uses a hand-written Default impl delegating to pr_synthetic_from_ci_default() — avoids the #[derive(Default)] footgun where a non-false default becomes false.
  • 8 KiB cap on PR_SYNTH_SPEC mirrors GATE_SPEC — good defence-in-depth against pathological front-matter.
  • match.ts strips the refs/heads/ prefix before picomatch comparison, so main and refs/heads/main both work in on.pr.branches.include.

Generated by Rust PR Reviewer for issue #922 · sonnet46 3.6M ·

Replaces the unshipped on.pr.synthetic-from-ci: true|false boolean with a two-value enum on.pr.mode: synthetic|policy (default synthetic). The two modes give the agent author a single coherent choice between the no-policy-required path and the operator-installed-branch-policy path.

Mode semantics:

* synthetic (default) — emit synthPr Setup-job step + downstream env coalescing + broadened conditions. CI trigger left at ADO default (all branches). Synth promotes CI builds with a matching open PR; non-matching CI builds self-skip cleanly via AW_SYNTHETIC_PR_SKIP. No Build Validation branch policy required.

* policy — omit all synth wiring AND emit 	rigger: none. Branch-policy-driven PR builds are the sole source of pipeline runs; feature-branch pushes no longer queue duplicate CI builds. Choose this when an operator has explicitly installed a Build Validation branch policy.

Previously, synthetic-from-ci: false omitted the synth wiring but did NOT suppress the CI trigger, so feature-branch pushes still queued CI builds that immediately bypassed the gate as 'not a PR build'. The new mode: policy closes that gap by emitting 	rigger: none, so every PR update fires exactly one PR-typed build.

Implementation:

* New PrMode { Synthetic, Policy } enum (Default = Synthetic) in src/compile/types.rs, replacing PrTriggerConfig.synthetic_from_ci: bool. PrTriggerConfig now derives Default.

* generate_ci_trigger gains a mode: policy → trigger: none branch after the existing pipeline/schedule suppression branch.

* All internal call sites (extensions/mod.rs, extensions/exec_context/mod.rs, common.rs::generate_agentic_depends_on derivation) replace p.synthetic_from_ci with matches!(p.mode, PrMode::Synthetic). The internal synthetic_pr_active: bool flag is preserved — it remains the right semantic abstraction.

* Fixtures: synthetic-pr-opt-out.md renamed to pr-mode-policy.md with mode: policy; synthetic-pr-default.md description cleaned (no longer references the removed narrowing). The policy fixture's integration test now asserts 	rigger: none is emitted.

* Unit tests rewritten around the two-mode contract: synth mode keeps the ADO default, policy mode emits trigger:none, pipeline-completion trigger still wins on priority. Schema tests cover all four cases (omitted, synthetic, policy, invalid value).

* Docs (front-matter.md), and prompts (create/update/debug) rewritten to present the two modes as a table and explain the policy mode's 	rigger: none emission.

No back-compat alias for synthetic-from-ci since the knob never shipped (still on the feature branch).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Ambitious and well-designed feature — architecture is clean, test coverage is strong, but there are two runtime correctness concerns about how ADO evaluates the synthesised env-block expressions that should be verified before merge.

Findings

🐛 Bugs / Logic Issues

1. Gate step uses a cross-job output reference within the same job (src/compile/filter_ir.rs, compile_gate_step_external)

The gate step is emitted into the Setup job (returned from AdoScriptExtension::setup_steps). When synthetic_pr_active = true, the env block gains:

AW_SYNTHETIC_PR: $[ coalesce(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], '') ]
ADO_PR_ID: $[ coalesce(variables['System.PullRequest.PullRequestId'], dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_ID']) ]

dependencies.Setup.outputs['synthPr.X'] is cross-job syntax — it resolves correctly in downstream jobs (the Agent job), but within the Setup job itself the synthPr step's isOutput=true variables are accessible as $(synthPr.AW_SYNTHETIC_PR) (macro) or $[ steps.synthPr.outputs['AW_SYNTHETIC_PR'] ] (runtime expression). The dependencies.JobName.outputs[...] form is undefined inside the producing job.

Practical consequence: bypass.ts checks process.env.AW_SYNTHETIC_PR === "true". If the coalesce cannot resolve the synthPr output, AW_SYNTHETIC_PR arrives empty and bypass takes the "not a PR build → auto-pass" path for CI-promoted builds — the gate skips, defeating the PR filter entirely.

2. $[ coalesce(...) ] in step-level env: blocks (filter_ir.rs, exec_context/pr.rs)

ADO documents $[...] runtime expressions as valid in variables: blocks and condition: fields, but step-level env: blocks use $(...) macro expansion. If ADO treats $[ coalesce(...) ] as a literal string in an env: value, all downstream coalescing silently fails: SYSTEM_PULLREQUEST_PULLREQUESTID, ADO_PR_ID, etc. arrive at the bundle as the raw expression string rather than the actual PR identifier.

The standard pattern for accessing cross-job outputs in a step env is to map the output to a job-level variable first:

# Agent job
variables:
  synthPrId: $[ dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_ID'] ]
steps:
  - bash: ...
    env:
      ADO_PR_ID: $(synthPrId)   # macro, not $[]

This would require a compiler-generated variables: block per job. Worth an explicit ADO YAML reference or empirical test before merge, given the deferred E2E.

3. AW_SYNTHETIC_PR_TARGETBRANCH format inconsistency (exec-context-pr-synth/index.ts)

setOutput("AW_SYNTHETIC_PR_TARGETBRANCH", pr.targetRefName ?? "") stores refs/heads/main (ADO API format). But System.PullRequest.TargetBranch predefined variable is main (no prefix). After coalescing, exec-context-pr.js sees main for real PR builds and refs/heads/main for synth-promoted ones. If the bundle uses this for checkout or merge-base resolution, synth builds break. Fix: strip the prefix before setOutput:

setOutput("AW_SYNTHETIC_PR_TARGETBRANCH", (pr.targetRefName ?? "").replace(/^refs\/heads\//, ""));

⚠️ Suggestions

4. Gate enforcement silently bypassed for real PR builds in synthetic mode (src/compile/common.rs, generate_agentic_depends_on)

With synthetic_pr_active=true + has_pr_filters=true, the agent-job condition includes eq(Build.Reason, 'PullRequest') as a standalone OR arm. A real PR build that fails the gate (prGate.SHOULD_RUN=false, e.g., title doesn't match *[review]*) still passes this arm and runs the agent. Pre-synthetic mode enforced the gate for real PR builds. If intentional for synth mode, a comment explaining the design decision would prevent confusion for authors upgrading existing on.pr.filters: agents (default is now mode: synthetic).

5. Stale compile-coalesce-env todo comment (src/compile/extensions/exec_context/pr.rs ~line 133)

"The step's condition is also broadened in the compile-coalesce-env todo" — the condition broadening is already implemented in the same function. Should be removed or updated.

✅ What Looks Good

  • Design: PrMode enum, PR_SYNTH_SPEC shape and 8 KiB size cap, exactly-one-match rule, and the comment explaining why the CI trigger must NOT be auto-narrowed to pr.branches.include (target vs source branch inversion) are all well-reasoned.
  • Test coverage: 26 new vitest cases, 4 new generate_ci_trigger Rust unit tests, round-trip spec tests, and 2 new snapshot fixtures are thorough. The assertions specifically checking no auto-narrowed CI trigger are valuable regression guards.
  • Bypass guard scoping: bypass.ts correctly limits the synthetic-PR suppression to PullRequest specs only; the four new bypass tests cover all edge cases cleanly.
  • Security posture: Treating PR_SYNTH_SPEC decode failures as hard errors (exit 1, not soft skip) is the right call — a malformed spec should never widen the match surface silently.

Generated by Rust PR Reviewer for issue #922 · sonnet46 4.9M ·

… enforcement

Addresses two real correctness bugs and one stale comment from the Rust
PR Reviewer feedback on #922.

(1) Gate-step same-job synthPr reference (filter_ir.rs::compile_gate_step_external):

The gate step is emitted into the Setup job (same job as `synthPr`),
so the env-block references `dependencies.Setup.outputs['synthPr.X']`
were silently wrong — that is cross-job syntax and resolves to null
inside the producing job, making `coalesce(...)` always return the
empty string. On synth-promoted CI builds this left
`AW_SYNTHETIC_PR=''`, so `gate/bypass.ts` took the "not a PR build"
auto-pass and the agent ran without `pr.filters` being evaluated at
all.

Fixed by switching the gate-step env coalesce to the same-job runtime
expression `variables['synthPr.X']`, which resolves step output
variables added to the producing job's variable scope. The Agent-job
env (in `exec_context/pr.rs`) keeps `dependencies.Setup.outputs[...]`
— that step runs cross-job where the dependencies form is the correct
one.

(2) Agent-job condition gate enforcement (common.rs::generate_agentic_depends_on):

With `mode: synthetic` + `pr.filters`, the synth branch emitted
`or(eq(Build.Reason, 'PullRequest'), eq(synthPr.AW_SYNTHETIC_PR,
'true'), eq(prGate.SHOULD_RUN, 'true'))`. The first two arms make
any PR build (real or synth) run the agent UNCONDITIONALLY —
silently bypassing the gate that `pr.filters` exists to enforce.

Replaced with `or(and(ne(Build.Reason, 'PullRequest'),
ne(synthPr.AW_SYNTHETIC_PR, 'true')), eq(prGate.SHOULD_RUN, 'true'))`
— non-PR / non-synth builds run unconditionally; real-PR and synth-PR
builds must pass the gate.

(3) Removed stale `compile-coalesce-env todo` comment in
`exec_context/pr.rs`; the work referenced is now implemented in the
same function.

Findings 2 and 3 from the same review were false alarms:
`$[ coalesce(...) ]` IS documented as valid in step-level `env:`
blocks, and `System.PullRequest.TargetBranch` is documented as the
full `refs/heads/<name>` form, matching `pr.targetRefName`. Both
clarified inline with MS-docs cross-references.

Test changes:

* `test_agentic_depends_on_synthetic_pr_active_emits_skip_guard_and_gate_enforced_pr_clause`
  (renamed from `_and_broader_pr_clause`) — pins the new AND-NOT shape
  and asserts the old permissive bypass arms are gone.
* `test_pr_filter_synth_mode_agent_condition_enforces_gate` —
  integration test that exercises the `pr-filter-tier1-agent.md`
  fixture (mode: synthetic + pr.filters) and asserts the Agent-job
  dependsOn condition contains both AND-NOT arms and none of the
  buggy bypass arms.
* `test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref` —
  integration test asserting the gate-step env uses
  `variables['synthPr.X']` (same-job) and contains no
  `dependencies.Setup.outputs['synthPr.X']` cross-job references
  inside the producing job.
* `test_synthetic_pr_default_emits_full_synth_wiring` — dropped the
  misleading `eq(synthPr.AW_SYNTHETIC_PR, 'true')` assertion (the
  fixture has no filters, so that string came from the
  exec-context-pr step, not the Agent-job condition).

All `cargo test` (1811 lib + 131 compiler-integration + others) and
`cargo clippy --all-targets --all-features` pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good overall — well-structured feature with strong test coverage and clear security reasoning. A few items worth fixing.

Findings

🐛 Bugs / Logic Issues

  • exec-context-pr-synth/index.ts, runtime contract comment (line ~443): The comment says 4. branches.include/exclude miss on BUILD_SOURCEBRANCH → skip, but the code never filters on.pr.branches against BUILD_SOURCEBRANCH. It fetches PRs by sourceRefName == sourceBranch, then filters the matched PRs by their targetRefName against spec.branches. This is correct ADO semantics (pr.branches = target branches), but the contract comment is wrong and could mislead a future debugger into thinking the spec's branch list is being applied to the feature branch itself.

  • ado-client.ts comment (line ~937): Says "first page (200 PRs)" — ADO's getPullRequests default page size without top is 100, not 200.

⚠️ Suggestions

  • AdoScriptExtension.synthetic_pr_active + pr_trigger_for_synth coupling: These two fields must be set together (trueSome(...)), enforced only by a runtime anyhow::anyhow! guard in setup_steps. The error message is clear and tests would catch a mismatch quickly, but wrapping them in a single Option<PrTriggerConfig> (using is_some() as the activation predicate) would make the invariant unrepresentable-wrong at the type level. Low urgency since the existing tests cover the pairing.

  • $[...] runtime expressions as unquoted YAML scalars (filter_ir.rs, exec_context/pr.rs): Values like $[ coalesce(variables['synthPr.AW_SYNTHETIC_PR'], '') ] are embedded as unquoted block-mapping scalars containing single quotes. ADO's YAML parser handles these fine in practice and the existing gate-step YAML does the same, but the strict YAML spec reserves ' as a scalar indicator at the start of a value. Wrapping these in YAML double quotes (e.g. "$[ coalesce(...) ]") would be strictly correct and is the form shown in ADO documentation. Not a functional bug with ADO.

✅ What Looks Good

  • Gate-bypass security fix is solid: The AND-NOT form and(ne(Build.Reason,'PullRequest'), ne(synthPr.AW_SYNTHETIC_PR,'true')) as the "unconditional pass" arm is exactly right. An earlier iteration with standalone eq(...) bypass arms would have let any PR build skip the gate — this version forces gate evaluation for all PR-flavour builds. The regression guards in the tests are excellent.
  • Same-job vs cross-job variable reference distinction is correctly handled and explicitly documented in both filter_ir.rs and exec_context/pr.rs.
  • Base64 spec injection safety: base64 alphabet contains no YAML-special characters; embedding in a double-quoted YAML scalar is safe.
  • PrMode::Synthetic as #[default] is the right choice — zero migration friction for existing on.pr agents, and the opt-out path (mode: policy) is explicit.
  • Test coverage is thorough: the regression guard for the gate-bypass bug (test_pr_filter_synth_mode_agent_condition_enforces_gate) and the flipped assertion in test_exec_context_pr_only_downloads_bundle_in_agent_job_not_setup both have good explanatory comments.

Generated by Rust PR Reviewer for issue #922 · sonnet46 2.5M ·

Four findings from the latest review on this PR:

(1) `exec-context-pr-synth/index.ts` runtime-contract comment was
misleading: step 4 said `branches.include/exclude miss on
BUILD_SOURCEBRANCH → skip`, but the bundle never filters
`on.pr.branches` against the source branch (that would be wrong —
`on.pr.branches` lists PR *target* branches per ADO semantics). The
actual flow fetches PRs by `sourceRefName == BUILD_SOURCEBRANCH`,
then filters the matched PRs by their `targetRefName` against
`spec.branches`. Updated the contract comment to reflect this.

(2) `ado-client.ts` comment claimed "first page (200 PRs)" — ADO's
`getPullRequests` default page size without an explicit `$top` is
100, not 200. Updated to match the SDK default.

(3) `AdoScriptExtension` previously had two coupled fields
(`synthetic_pr_active: bool` + `pr_trigger_for_synth:
Option<PrTriggerConfig>`) whose pairing was enforced only by a
runtime `anyhow!` guard in `setup_steps`. Refactored to a single
field `pr_trigger_for_synth: Option<...>` whose `is_some()` IS the
activation predicate, exposed as `synthetic_pr_active()` for callers
that read it. The invariant is now unrepresentable-wrong at the type
level; the runtime guard is gone. Tests and `collect_extensions`
updated accordingly.

(4) Wrapped `$[ ... ]` runtime expressions in YAML double quotes in
the step `env:` blocks emitted by `filter_ir.rs::compile_gate_step_external`
and `exec_context/pr.rs::prepare_step`. The values contain single
quotes (`variables['System.PullRequest.X']`) and although ADO's YAML
parser accepts them unquoted in practice, double-quoting is the form
shown in ADO docs and is strictly conformant to the YAML spec (which
reserves `'` as a scalar indicator). Adjusted the corresponding
integration-test assertions to match.

Validation:
- `cargo build` clean
- `cargo test` (1811 lib + 131 compiler-integration + others) 0 failures
- `cargo clippy --all-targets --all-features` clean
- `npm test` in scripts/ado-script/ — 281/281 vitest passes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — solid design with a few minor observations worth addressing before merge.


Findings

⚠️ Suggestions

  • scripts/ado-script/src/shared/ado-client.ts:117-127 — Missing null guard on listActivePullRequestsBySourceRef

    The azure-devops-node-api SDK's getPullRequests may return null instead of [] when no PRs match (the SDK uses null for empty bodies in some REST responses). Calling .filter(...) on null at index.ts:103 would throw at runtime. The pattern is already established in this file — getIterationChanges uses result.changeEntries ?? []. A one-liner guard is all it takes:

    // ado-client.ts
    return (await git.getPullRequests(
      repoId,
      { sourceRefName, status: PullRequestStatus.Active },
      project,
    )) ?? [];
  • src/compile/extensions/exec_context/pr.rs:121-178condition: string is non-trivially long and untested for the synth+no-synth branches

    The condition string inside the format is embedded verbatim into the condition: YAML field. When synthetic_pr_active is true, the condition is:

    or(eq(variables['Build.Reason'], 'PullRequest'), eq(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], 'true'))
    

    This is only tested indirectly via the snapshot fixtures. A targeted unit test verifying both the synth and non-synth step outputs would help guard against accidental regressions here, similar to how the gate-step same-job vs cross-job distinction is tested.

  • src/compile/types.rsSanitizeConfigTrait impl for PrTriggerConfig doesn't mention mode

    The new mode: PrMode field is a Copy enum with no string content, so there's nothing to sanitize. Still worth a one-line comment in the sanitize_config_fields impl explaining why mode is intentionally absent:

    fn sanitize_config_fields(&mut self) {
        // mode (PrMode enum) has no string content to sanitize.
        if let Some(ref mut b) = self.branches { ... }

✅ What Looks Good

  • Same-job vs cross-job reference distinction is well-handled. The gate step (Setup job) correctly uses variables['synthPr.X'] while the Agent-job dependsOn condition uses dependencies.Setup.outputs['synthPr.X']. This is clearly documented in the function doc-comments and the regression tests for both forms (test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref) are excellent.

  • AdoScriptExtension::pr_trigger_for_synth: Option<...> as single source of truth. Encoding "synth active" in the Option type rather than a separate bool eliminates an invariant-coordination bug class. Clean design.

  • Gate bypass change in bypass.ts. The spec.context.build_reason === "PullRequest" && AW_SYNTHETIC_PR === "true" guard is correctly scoped to PR gates only — pipeline-completion gates are unaffected. No way for a non-PR synthetic flag to mistakenly disable the bypass for unrelated gate types.

  • spec.ts validation is correctly strict. Spec corruption is a hard failure (return 1), not a soft skip. This is the right call — silently skipping on a tampered spec would widen the attack surface. The comment explaining this in the source is good.

  • generate_agentic_depends_on's gate enforcement for synth mode is correct. The AND-NOT clause ne(Build.Reason, 'PullRequest') AND ne(synthPr.AW_SYNTHETIC_PR, 'true') correctly prevents the gate being bypassed for either real PR builds or synth-promoted CI builds. The regression test test_agentic_depends_on_synthetic_pr_active_emits_skip_guard_and_gate_enforced_pr_clause with its defensive "must NOT contain the buggy bypass arms" assertions is exactly the right guard here.

  • build_pr_synth_spec 8 KiB cap mirrors the existing GATE_SPEC ceiling. Consistent defence-in-depth.

  • Sanitize is called before collect_extensions / compile_shared (in mod.rs:150), so branch/path globs embedded in PR_SYNTH_SPEC are already sanitized. No injection window.

Generated by Rust PR Reviewer for issue #922 · sonnet46 3.9M ·

Three minor suggestions from the latest review.

(1) `ado-client.ts::listActivePullRequestsBySourceRef` — added `?? []`
guard on the SDK call. azure-devops-node-api's `getPullRequests` can
return `null` instead of `[]` on empty REST bodies; the bundle's
`.filter(...)` on the result would have thrown at runtime. Matches
the established pattern (`getIterationChanges` uses
`result.changeEntries ?? []`).

(2) `exec_context/pr.rs` — added a `#[cfg(test)] mod tests` block
with two targeted unit tests pinning the emitted YAML for
`PrContextContributor::prepare_step`:

  * `prepare_step_synth_active_emits_coalesced_env_and_broadened_condition`
    — asserts the synth-mode env coalesces (PR id + target branch,
    YAML double-quoted runtime expressions) and the broadened
    `or(eq(Build.Reason,'PullRequest'), eq(synthPr.AW_SYNTHETIC_PR,
    'true'))` condition.
  * `prepare_step_synth_inactive_emits_plain_macros_and_narrow_condition`
    — asserts plain `$(System.PullRequest.*)` macros + narrow
    `eq(Build.Reason,'PullRequest')` condition + defensive
    "no synthPr references in synth-inactive output".

  Previously these were only covered indirectly via the
  `synthetic-pr-default.md` snapshot fixture.

(3) `types.rs::SanitizeConfigTrait for PrTriggerConfig` — added a
one-line comment explaining why `mode` is intentionally absent from
`sanitize_config_fields` (it's a `Copy` enum with no string content
and any malformed input is rejected at deserialisation).

Validation:
- `cargo build` clean
- `cargo test` 1813 lib + 131 compiler-integration + others, 0 failures
- `cargo clippy --all-targets --all-features` clean
- `npm test` in scripts/ado-script/ — 281/281 vitest passes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Solid, well-structured implementation — looks good with a few small items worth addressing.

Findings

🐛 Bugs / Logic Issues

  • tests/compiler_tests.rs:4251 — Stale test name contradicts the assertion it contains.
    test_exec_context_pr_only_downloads_bundle_in_agent_job_not_setup was renamed in intent but not in name: the test now asserts setup.contains("Download ado-aw scripts") (the download IS expected in Setup), but the function name says "not setup". It passes, but the next engineer reading the failure message will be confused. Should be renamed to something like test_exec_context_pr_downloads_bundle_in_both_jobs_with_synth_mode.

⚠️ Suggestions

  • synthetic_pr_active predicate duplicated in three places.
    The expression front_matter.pr_trigger().is_some_and(|p| matches!(p.mode, PrMode::Synthetic)) appears independently in compile_shared (common.rs ~line 3374), ExecContextExtension::new (exec_context/mod.rs ~line 128), and collect_extensions (extensions/mod.rs ~line 703). If a third PrMode variant is ever added the activation logic must be updated in all three spots. A FrontMatter::is_synthetic_pr() -> bool helper would be the idiomatic fix.

  • src/compile/types.rs — Truncated doc comment on PrTriggerConfig.mode.
    /// Whether to synthesise PullRequest semantics on CI builds when an / /// PR-trigger mode. reads as a cut-off sentence. The "when an PR-trigger mode" fragment has no predicate. Suggest rewriting to something like: /// Determines how PR builds reach the pipeline; see [PrMode].

✅ What Looks Good

  • Same-job vs. cross-job ADO variable reference is handled correctly and heavily documented: the gate step (inside Setup) uses variables['synthPr.X']; the Agent-job condition and exec-context-pr step use dependencies.Setup.outputs['synthPr.X']. Two dedicated regression tests (test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref, prepare_step_synth_active_emits_coalesced_env_and_broadened_condition) pin both forms.
  • Gate-bypass security invariant is correctly enforced: the Agent-job condition requires the gate to pass for both real-PR and synth-promoted builds (eq(prGate.SHOULD_RUN, 'true')), with no standalone OR arm that could let a PR build bypass the filter. The defensive regression test (test_agentic_depends_on_synthetic_pr_active_emits_skip_guard_and_gate_enforced_pr_clause) explicitly asserts the "buggy bypass arms" are absent.
  • PrMode as a closed enum — invalid front-matter values are rejected at deserialization time, the SanitizeConfigTrait impl correctly explains no sanitization is needed, and the test_pr_trigger_config_mode_invalid_value_errors test pins this.
  • PR_SYNTH_SPEC base64-encoding is safe for YAML embedding (base64 alphabet has no YAML-special characters), the 8 KiB cap matches the GATE_SPEC precedent, and oversize rejection is tested.
  • Step ordering (synthPr → prGate) is enforced in code and pinned by setup_steps_emits_synth_step_before_gate_when_both_active.
  • bypass.ts change is minimal and correctly scoped — AW_SYNTHETIC_PR suppresses the bypass only when spec.context.build_reason === "PullRequest", so pipeline-completion specs are unaffected.

Generated by Rust PR Reviewer for issue #922 · sonnet46 2.5M ·

Three items from the latest review.

(1) Stale test name (`tests/compiler_tests.rs` ~L4239) —
`test_exec_context_pr_only_downloads_bundle_in_agent_job_not_setup`
was the original intent, but the body was later updated to assert
the Setup job DOES carry the bundle download (the synthPr step is a
Setup-job bundle consumer). Renamed to
`test_exec_context_pr_downloads_bundle_in_both_jobs_with_synth_mode`
and updated the doc-comment to describe the two consumers.

(2) `is_synthetic_pr()` helper on `FrontMatter` —
`front_matter.pr_trigger().is_some_and(|p| matches!(p.mode,
PrMode::Synthetic))` was duplicated verbatim in three places
(`compile_shared`, `ExecContextExtension::new`,
`collect_extensions`). Extracted to `FrontMatter::is_synthetic_pr()`
in `src/compile/types.rs` so a future `PrMode` variant can't drift
across the three sites. All three call sites now use the helper.

(3) Truncated doc-comment on `PrTriggerConfig.mode` — the previous
text ("Whether to synthesise PullRequest semantics on CI builds when
an / PR-trigger mode. Drives whether ...") read as a cut-off
sentence. Rewrote to "Determines how `on.pr` builds reach the
pipeline; see [`PrMode`] for the two supported strategies
(`synthetic`, `policy`). Defaults to [`PrMode::Synthetic`]."

Validation:
- `cargo build` clean
- `cargo test` 1813 lib + 131 compiler-integration + others, 0 failures
- `cargo clippy --all-targets --all-features` clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🔍 Rust PR Review

Summary: Looks good — well-designed feature with strong correctness properties and comprehensive tests. Two minor suggestions below.

Findings

⚠️ Suggestions

  • src/compile/extensions/ado_script.rs:57–92 — Two impl AdoScriptExtension blocks now exist back-to-back: one added for synthetic_pr_active(), and the pre-existing one for lowered_checks(). Both are valid Rust, but they could be merged to reduce noise. Low priority.

  • scripts/ado-script/src/exec-context-pr-synth/match.ts:47,62picomatch(g, { dot: true }) is called inside .some(), creating a fresh compiled matcher on every (pattern, value) pair. The function is called once per build so this has zero practical impact, but the idiomatic form would compile each pattern once:

    const matchers = normIncludes.map(g => picomatch(g, { dot: true }));
    const includeMatches = normIncludes.length === 0 || matchers.some(m => m(normalised));

✅ What Looks Good

  • Same-job vs. cross-job ADO reference distinction is correctly handled: the gate step (lives in Setup job) uses variables['synthPr.X'], while the exec-context-pr step and Agent-job dependsOn condition (live downstream of Setup) use dependencies.Setup.outputs['synthPr.X']. This is the most subtle correctness invariant in the PR and it's right everywhere, with clear doc-comments explaining why.

  • Gate bypass enforced for both real-PR and synth-promoted builds: the generate_agentic_depends_on condition correctly uses the AND-NOT form (ne(Build.Reason,'PullRequest') AND ne(synthPr.AW_SYNTHETIC_PR,'true')) rather than permissive OR-arms that would silently bypass the gate. The corresponding regression guard test (test_agentic_depends_on_synthetic_pr_active_emits_skip_guard_and_gate_enforced_pr_clause) is thorough.

  • is_synthetic_pr() centralization: the single predicate in FrontMatter eliminates the risk of the three call-sites drifting if a future PrMode variant is added. The type-level encoding via Option<PrTriggerConfig> (present ↔ active) is a nice touch in AdoScriptExtension.

  • Hard vs. soft failure contract in exec-context-pr-synth/index.ts: spec decode errors and infra failures exit non-zero (step failure); no-match / multi-match cases emit AW_SYNTHETIC_PR_SKIP=true and exit 0. This keeps builds green and prevents noise for the common "CI push with no open PR" case.

  • PR_SYNTH_SPEC security boundary: user-supplied globs are serialised via serde_json (properly escaping any special characters), base64-encoded, and validated by the TypeScript bundle's decodeSpecvalidate chain before any pattern matching. The 8 KiB cap mirrors the GATE_SPEC ceiling.

  • CI trigger non-narrowing rationale is documented in three places (code comment in generate_ci_trigger, docs/front-matter.md, and the test assertion message) — future maintainers won't accidentally "fix" it.

Generated by Rust PR Reviewer for issue #922 · sonnet46 2.5M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make on: pr a first-class trigger by deriving PR context from the ADO REST API on CI-triggered builds

1 participant